外部活动导入管理 API
概述
该接口用于从外部票务平台(Eventbrite、DICE.fm、PoshVIP、RA.co、Shotgun)导入活动数据到 Katana 系统。接口通过网页抓取/API 调用技术获取活动信息,并自动创建活动实体及关联的票务产品。
[!warning] 管理员接口 此接口仅限管理员使用,需要通过
Pear-Client-Id和Pear-Client-Secret进行身份验证。[!info] 核心能力
- 自动抓取外部平台活动数据
- 智能解析活动信息(标题、时间、场地、票价)
- 上传并处理活动海报
- 自动计算手续费和票价分离
- 一键创建完整活动及票务产品
接口信息
| 属性 | 值 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /external-event-import/admin/:userId |
| 内容类型 | application/json |
| 认证方式 | AdminGuard(Header 认证) |
| 代码位置 | src/web-scraper/admin/event-scraper.admin.controller.ts:50 |
请求头 (Headers)
| Header 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
Content-Type |
string | ✅ | 固定值:application/json |
Pear-Client-Id |
string | ✅ | 管理员客户端 ID(从环境变量 PEAR_CLIENT_ID 获取) |
Pear-Client-Secret |
string | ✅ | 管理员客户端密钥(从环境变量 PEAR_CLIENT_SECRET 获取) |
timezone |
string | ❌ | 时区信息(例如:Asia/Shanghai) |
[!note] 认证实现 认证逻辑位于
src/auth/admin.guard.ts:15,通过比对 Header 中的凭据与环境变量进行验证。
路径参数 (Path Parameters)
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId |
string (UUID) | ✅ | 目标用户的 ID,活动将关联到此用户 |
请求体 (Body)
EventImportRequest
代码位置: src/web-scraper/web-scraper.interface.ts:19
{
type: EventImportType;
url: string;
}
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
string (enum) | ✅ | 活动来源平台类型,见下方枚举值 |
url |
string | ✅ | 活动 URL 地址(支持带或不带协议前缀) |
EventImportType 枚举值
代码位置: src/web-scraper/web-scraper.interface.ts:11
| 值 | 平台 | URL 格式要求 | 抓取方式 |
|---|---|---|---|
eventbrite |
Eventbrite | https://www.eventbrite.com/e/* |
页面 window.__SERVER_DATA__ |
dicefm |
DICE.fm | https://dice.fm/event/* |
Meta 标签 + API |
poshvip |
PoshVIP | https://posh.vip/e/* |
self.__next_f.push |
raco |
RA.co | 待补充 | 待补充 |
shotgun |
Shotgun | 待补充 | 待补充 |
响应结构
成功响应
HTTP 状态码: 200 OK
响应体: void(无返回内容)
[!note] 响应说明 接口执行成功后不返回具体内容。活动是否创建成功需要通过其他接口查询确认(如
GET /product-event/:id/details)。
错误响应格式
{
"statusCode": 400,
"message": "错误描述",
"error": "Bad Request"
}
业务流程详解
流程图
graph TD
A[接收请求] --> B[验证用户存在性]
B --> C[验证并规范化 URL]
C --> D{平台类型判断}
D -->|eventbrite| E[Eventbrite 抓取流程]
D -->|dicefm| F[DICE.fm 抓取流程]
D -->|poshvip| G[PoshVIP 抓取流程]
D -->|其他| H[抛出不支持错误]
E --> I[转换为标准数据格式]
F --> I
G --> I
I --> J[上传海报到 Cloudinary]
J --> K[处理时区信息]
K --> L[计算票价和手续费]
L --> M[组装活动创建请求]
M --> N[调用 ProductEventService.create]
N --> O[返回成功]
B -->|用户不存在| Z[抛出 BadRequestException]
C -->|URL 无效| Z
E -->|抓取失败| Z
I -->|无票务信息| Z
N -->|创建失败| Z
步骤 1:用户验证
代码位置: src/web-scraper/base-event-import.service.ts:63
protected async validateUser(userId: string) {
const pearUser = await this.userMetaRepository.findUserById(userId);
if (!pearUser) {
throw new BadRequestException(`Invalid user ID: ${userId}`);
}
return pearUser;
}
业务逻辑:
- 通过
UserMetaRepository.findUserById()查询用户是否存在 - 用户不存在时抛出
BadRequestException,错误消息:Invalid user ID: {userId}
步骤 2:URL 验证与规范化
代码位置: src/web-scraper/base-event-import.service.ts:74
protected normalizeUrl(url: string): string {
return url.includes('://') ? url : `https://${url}`;
}
业务逻辑:
- 自动为 URL 添加
https://前缀(如果缺失) - 支持用户输入不带协议的 URL(如
eventbrite.com/e/xxx)
平台特定验证
Eventbrite (src/web-scraper/eventbrite-scraper.service.ts:42):
// 域名验证
if (
parsedUrl.hostname !== 'eventbrite.com' &&
!parsedUrl.hostname.endsWith('.eventbrite.com')
) {
throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}
// 路径验证 - 必须以 /e 开头
const parts = parsedUrl.pathname.split('/');
if (parts[1] !== 'e') {
throw new BadRequestException(`It is not a valid ticketing page`);
}
DICE.fm (src/web-scraper/dice-fm-scraper.service.ts:91):
if (
parsedUrl.hostname !== 'dice.fm' &&
!parsedUrl.hostname.endsWith('.dice.fm')
) {
throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}
PoshVIP (src/web-scraper/posh-vip-scraper.service.ts:68):
if (
parsedUrl.hostname !== 'posh.vip' &&
!parsedUrl.hostname.endsWith('.posh.vip')
) {
throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}
// 路径验证: /e/{eventSlug}
const parts = parsedUrl.pathname.split('/');
if (parts[1] !== 'e' || parts.length < 3) {
throw new BadRequestException(`It is not a valid PoshVip event page`);
}
步骤 3:数据抓取
Eventbrite 数据抓取
代码位置: src/web-scraper/eventbrite-scraper.service.ts:103
抓取方式: 从页面 <script> 标签中提取 window.__SERVER_DATA__
private async fetchEventbriteEventData(
normalizedUrl: string,
): Promise<EventBriteEvent | null> {
const eventbriteResponse = await axios.get<string>(normalizedUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...',
},
});
const $ = cheerio.load(eventbriteResponse.data);
$('script').each((_, element) => {
const scriptContent = $(element).html();
if (scriptContent && scriptContent.includes('window.__SERVER_DATA__')) {
const regex = /window\.__SERVER_DATA__\s*=\s*({.*?});/s;
const match = regex.exec(scriptContent);
if (match && match[1]) {
serverData = JSON.parse(match[1]) as EventBriteEvent;
return false;
}
}
});
return serverData;
}
数据结构: src/web-scraper/eventbrite-scraper.interface.ts:2
interface EventBriteEvent {
event: {
id: string;
name: string;
start: { local: string; timezone: string };
end: { local: string };
};
components: {
eventHero: { items: [{ croppedLogoUrl940: string }] };
eventMap: {
venueName: string;
venueAddress: string;
location: { latitude: number; longitude: number };
};
};
event_listing_response: {
tickets: {
ticketClasses: [{
quantityRemaining: number;
variants: [{
free: boolean;
name: string;
totalCost: { value: number }; // 单位:分
}];
}];
};
structuredContent: {
modules: [{ text: string }];
};
};
}
DICE.fm 数据抓取
代码位置: src/web-scraper/dice-fm-scraper.service.ts:182
两步抓取流程:
- 提取 Event ID(从 HTML meta 标签):
private async extractEventIdFromPage(url: string): Promise<string | null> {
const response = await axios.get<string>(url, {
headers: {
'User-Agent': 'Mozilla/5.0...',
'Accept': 'text/html,application/xhtml+xml...',
},
});
const $ = cheerio.load(response.data);
// 方式 1: 从 meta 标签提取
const eventId = $('meta[property="product:retailer_item_id"]').attr('content');
if (eventId && /^[a-f0-9]{24}$/.test(eventId)) {
return eventId;
}
// 方式 2: 从 JSON-LD script 标签提取
$('script[type="application/ld+json"]').each((_, element) => {
const scriptContent = $(element).html();
if (scriptContent) {
const jsonData = JSON.parse(scriptContent);
if (jsonData['@graph']) {
for (const item of jsonData['@graph']) {
if (item['@type'] === 'Event' && item.identifier) {
return item.identifier;
}
}
}
}
});
return null;
}
- 调用 API 获取详细数据:
private async scrapeDiceFmEventData(
eventId: string,
): Promise<DiceFmEventData | null> {
const apiURL = `https://api.dice.fm/events/${eventId}/ticket_types`;
const response = await axios.get<DiceFmEventData>(apiURL, {
headers: {
'User-Agent': 'Mozilla/5.0...',
'Accept': 'application/json',
},
});
return response.data;
}
PoshVIP 数据抓取
代码位置: src/web-scraper/posh-vip-scraper.service.ts:155
抓取方式: 从 self.__next_f.push 提取数据
private async scrapePoshVipEventData(
url: string,
): Promise<PoshVipEventData | null> {
const response = await axios.get<string>(url, {
headers: {
'User-Agent': 'Mozilla/5.0...',
},
});
const $ = cheerio.load(response.data);
$('script').each((_, element) => {
const scriptContent = $(element).html();
if (scriptContent && scriptContent.includes('self.__next_f.push')) {
const regex = /self\.__next_f\.push\(\[1,\s*"(.+?eventResponse.+?)"\]\)/s;
const match = regex.exec(scriptContent);
if (match && match[1]) {
const unescapedData = match[1]
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
const eventResponseMatch = unescapedData.match(
/"eventResponse":\s*(\{.*?"kickbackData":\s*\{[^}]*\}[^}]*\})/s,
);
if (eventResponseMatch) {
eventDataFromPage = JSON.parse(eventResponseMatch[1]);
}
}
}
});
// 额外调用 API 获取票务信息
if (eventDataFromPage.eventId) {
const tickets = await this.getPoshVipTickets(eventDataFromPage.eventId);
eventDataFromPage.tickets = tickets;
}
return eventDataFromPage;
}
步骤 4:数据转换
代码位置: src/web-scraper/base-event-import.service.ts:210
将各平台的数据转换为标准的 EventImportData 格式:
interface EventImportData {
title: string;
description?: string;
venue: string;
location: string;
startDateDisplay: string; // 格式: YYYY-MM-DD HH:mm:ss
endDateDisplay: string;
posterUrl?: string;
timezone?: PrismaJson.Timezone;
coordinates?: { lat: number; lng: number };
tickets: TicketImportData[];
lineup?: LineupImportData[];
}
interface TicketImportData {
title: string;
price: number;
description?: string;
quantityAvailable?: number;
validType?: string;
}
转换示例(Eventbrite):
const standardEventData: EventImportData = {
title: decodeURI(finalServerData.event.name),
description: this.formatEventbriteDescription(finalServerData),
venue: finalServerData.components.eventMap.venueName,
location: finalServerData.components.eventMap.venueAddress,
startDateDisplay: finalServerData.event.start.local.replace('T', ' '),
endDateDisplay: finalServerData.event.end.local.replace('T', ' '),
posterUrl: finalServerData.components.eventHero.items[0].croppedLogoUrl940,
coordinates: {
lat: finalServerData.components.eventMap.location.latitude,
lng: finalServerData.components.eventMap.location.longitude,
},
tickets: this.extractEventbriteTickets(finalServerData),
};
步骤 5:海报上传
代码位置: src/web-scraper/base-event-import.service.ts:161
protected async handlePosterUpload(
posterUrl: string,
productCreateRequest: ProductEventCreateRequest,
): Promise<void> {
const uploadPoster = await this.cloudinaryService.upload(
decodeURI(posterUrl),
{ resource_type: 'image' },
);
productCreateRequest.poster = {
src: uploadPoster.secure_url,
width: uploadPoster.width,
height: uploadPoster.height,
mediaType: MediaType.IMAGE,
mediaSrc: undefined as unknown as string,
mediaDuration: 0,
};
}
业务逻辑:
- 使用 CloudinaryService 上传外部图片到 Katana 的 Cloudinary 账户
- 获取图片的宽高信息并设置到媒体对象
- 解码 URL 中的特殊字符(如
%20→ 空格)
步骤 6:时区处理
代码位置: src/web-scraper/base-event-import.service.ts:183
protected async handleTimezone(
eventData: EventImportData,
productCreateRequest: ProductEventCreateRequest,
): Promise<void> {
if (eventData.timezone) {
// 优先使用页面中的时区
productCreateRequest.timezone = eventData.timezone;
} else if (eventData.coordinates) {
// 如果没有时区但有坐标,通过 MapService 反查
productCreateRequest.timezone = await this.getTimezoneByCoordinates(
eventData.coordinates.lat,
eventData.coordinates.lng,
);
}
}
protected async getTimezoneByCoordinates(
lat: number,
lng: number,
): Promise<PrismaJson.Timezone> {
return this.mapService.getTimeZoneByLatAndLng(lat, lng);
}
业务逻辑:
- 优先级 1: 使用页面返回的时区信息(如 Eventbrite 的
event.start.timezone) - 优先级 2: 如果没有时区但有经纬度坐标,调用 MapService 通过 Google Timezone API 反查
- 时区格式: PrismaJson.Timezone(包含 timeZoneId、offset 等信息)
步骤 7:票价与手续费计算
代码位置: src/web-scraper/base-event-import.service.ts:81
protected async processTicketPriceAndFees(
price: number,
customFeeConfig: PrismaJson.CustomFeeConfig,
user: UserEntity,
): Promise<{ ticketPrice: number; fees: PrismaJson.TransactionFee }> {
let ticketPrice = 0;
let fees: PrismaJson.TransactionFee = {
platformFee: 0,
customFee: 0,
customFeeBreakdown: {},
transactionItemFee: 0,
};
if (price > 0) {
// 从总价中分离手续费
fees = await this.transactionFeeService.calcTransactionItemFeeFromFinalPrice(
price,
ProductType.TICKET,
customFeeConfig,
user!.transactionFeeConfig,
);
// 票面价格 = 总价 - 手续费
ticketPrice = Money.subtract(price, fees.transactionItemFee);
}
return { ticketPrice, fees };
}
业务逻辑:
- 输入: 外部平台显示的最终售价(包含手续费)
- 计算: 调用
TransactionFeeService.calcTransactionItemFeeFromFinalPrice()反向计算transactionItemFee: 手续费金额ticketPrice: 去除手续费后的票面价格
- 免费票:
price = 0时,所有费用字段均为 0
步骤 8:票务描述处理
代码位置: src/web-scraper/base-event-import.service.ts:139
protected processTicketDescription(description?: string) {
const bodyText = description || '';
const bodyHtml = bodyText
? `<p class=\\"katana__paragraph katana__paragraph--align-left\\" dir=\\"ltr\\"><span style=\\"white-space: pre-wrap;\\">${bodyText}</span></p>`
: '';
const bodyJson = bodyText
? `{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"${bodyText}\\",\\"type\\":\\"extended-text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"katana-paragraph\\",\\"version\\":1,\\"textFormat\\":0,\\"textStyle\\":\\"\\"}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}`
: '';
return { bodyText, bodyHtml, bodyJson };
}
业务逻辑:
- bodyText: 纯文本描述
- bodyHtml: HTML 格式(用于邮件模板)
- bodyJson: Lexical 富文本 JSON 格式(用于前端编辑器)
步骤 9:票务图片处理
代码位置: src/web-scraper/base-event-import.service.ts:111
protected createTicketImages(posterUrl?: string) {
if (posterUrl) {
return [{
src: posterUrl,
height: 630,
width: 1200,
position: 1,
mediaType: MediaType.IMAGE,
}];
}
// 默认票务图片
return [{
src: 'https://res.cloudinary.com/dr9io1zjv/v1755656046/uploaded_images/cg5igrjqku4omywtu0ct.png',
height: 1000,
width: 1000,
position: 1,
mediaType: MediaType.IMAGE,
}];
}
业务逻辑:
- 如果有活动海报,使用活动海报作为票务图片
- 否则使用默认的票务图片(Cloudinary 上的通用图片)
步骤 10:库存数量处理
代码位置: src/web-scraper/base-event-import.service.ts:279
const quantity = ticket.quantityAvailable ?? (ticket.validType === 'VALID' ? 100 : 0);
业务逻辑:
- 优先级 1: 使用平台返回的
quantityAvailable - 优先级 2: 如果平台未返回数量,根据
validType判断:validType === 'VALID': 默认库存 100- 其他情况: 默认库存 0
步骤 11:活动创建
代码位置: src/web-scraper/base-event-import.service.ts:325
protected async createEventProduct(
productCreateRequest: ProductEventCreateRequest,
userId: string,
): Promise<void> {
try {
await this.productEventService.create(productCreateRequest, userId);
console.log(`Successfully created event`);
} catch (error) {
console.error('Error creating product event:', error);
throw new BadRequestException(
`Failed to create event: ${(error as Error).message}`,
);
}
}
调用链:
BaseEventImportService.createEventProduct()
↓
ProductEventService.create() // src/product-event/product.event.service.ts
↓
ProductEventV2Service.createEvent() // src/product-event/v2/product.event.v2.service.ts:98
↓
doCreateEvent() // src/product-event/v2/product.event.v2.service.ts:152
↓
repo.createV2() // 创建 Event 实体
创建逻辑 (src/product-event/v2/product.event.v2.service.ts:152):
async doCreateEvent(request: CreateEventV2Request, userId: string) {
await this.checkCreateRequest(request);
this.enrichWithNormalizeLocationDetailsOrThrow(request);
let event;
if (request.id) {
// 更新现有活动
const existingEvent = await this.repo.findById(request.id);
if (!existingEvent) {
throw new ResourceNotFound({ message: `Event ${request.id} not found` });
}
if (existingEvent.curatorId !== userId) {
throw new BadRequestException('Access denied: You do not own this event');
}
event = await this.repo.updateV2(request.id, request);
} else {
// 创建新活动
event = await this.repo.createV2(request, userId);
}
// 设置创建步骤为 ADD_EVENT_DETAILS
if (event.creationStep !== EventCreationStep.COMPLETED) {
await this.repo.updateCreationStep(
event.id,
EventCreationStep.ADD_EVENT_DETAILS,
);
}
const entity = await this.productEventService.findUniqueEventEntityOrThrow(event.id);
await this.productEventPublisher.onCreate(entity);
return entity;
}
异常场景
错误响应格式
{
"statusCode": 400,
"message": "错误描述",
"error": "Bad Request"
}
异常场景清单
| HTTP 状态码 | 错误场景 | 错误信息 | 代码位置 |
|---|---|---|---|
400 |
用户 ID 无效 | Invalid user ID: {userId} |
base-event-import.service.ts:66 |
400 |
URL 格式错误 | Invalid URL: {url} |
eventbrite-scraper.service.ts:46 |
400 |
非有效票务页面 | It is not a valid ticketing page |
eventbrite-scraper.service.ts:53 |
400 |
无法获取页面数据 | Unable to retrieve server data from the link |
eventbrite-scraper.service.ts:60 |
400 |
无票务信息 | No ticket information is available. The event may have expired or is no longer valid |
eventbrite-scraper.service.ts:68 |
400 |
活动创建失败 | Failed to create event: {error message} |
base-event-import.service.ts:334 |
400 |
Event ID 未找到 | Event ID not found in page |
dice-fm-scraper.service.ts:110 |
400 |
DICE.fm 活动不存在 | Event not found on DICE.fm |
dice-fm-scraper.service.ts:291 |
401 |
认证失败 | Client ID 或 Secret 不匹配 | admin.guard.ts:21 |
403 |
权限不足 | 非 Admin 角色 | - |
500 |
JSON 解析失败 | (记录到 console,返回通用错误) | eventbrite-scraper.service.ts:127 |
Eventbrite 特定错误
域名验证失败 (
eventbrite-scraper.service.ts:42)- 域名不是
eventbrite.com或其子域名
- 域名不是
路径格式错误 (
eventbrite-scraper.service.ts:52)- URL 路径第二段不是
e - 例如:
https://eventbrite.com/invalid/tickets会失败
- URL 路径第二段不是
页面数据缺失 (
eventbrite-scraper.service.ts:119)- 页面中不存在
window.__SERVER_DATA__变量 - JSON 解析失败
- 页面中不存在
活动已过期 (
eventbrite-scraper.service.ts:66)ticketClasses数组为空quantityRemaining为 0
DICE.fm 特定错误
Event ID 提取失败 (
dice-fm-scraper.service.ts:109)- Meta 标签中未找到 Event ID
- JSON-LD 中也未找到 Event ID
- Event ID 格式不是 24 位 hex 字符串
API 调用失败 (
dice-fm-scraper.service.ts:290)- 404: 活动不存在
- 其他网络错误
PoshVIP 特定错误
Event Slug 提取失败 (
posh-vip-scraper.service.ts:82)- URL 路径格式不正确(需要
/e/{slug})
- URL 路径格式不正确(需要
数据抓取失败 (
posh-vip-scraper.service.ts:208)- 页面中未找到
self.__next_f.push数据 - JSON 解析失败
- 页面中未找到
示例
成功请求示例
curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
-H 'Content-Type: application/json' \
-H 'Pear-Client-Id: your-client-id' \
-H 'Pear-Client-Secret: your-client-secret' \
-H 'timezone: Asia/Shanghai' \
-d '{
"type": "eventbrite",
"url": "https://www.eventbrite.com/e/the-association-cocktail-classes-tickets-164264039163"
}'
Eventbrite 响应示例
成功: HTTP 200 OK(无响应体)
失败示例 1 - 无效用户:
{
"statusCode": 400,
"message": "Invalid user ID: ad57de31-bf92-413b-ae4d-8609b5ff0680",
"error": "Bad Request"
}
失败示例 2 - 无效 URL:
{
"statusCode": 400,
"message": "Invalid URL: https://example.com/event",
"error": "Bad Request"
}
失败示例 3 - 非票务页面:
{
"statusCode": 400,
"message": "It is not a valid ticketing page",
"error": "Bad Request"
}
失败示例 4 - 无票务信息:
{
"statusCode": 400,
"message": "No ticket information is available. The event may have expired or is no longer valid",
"error": "Bad Request"
}
DICE.fm 请求示例
curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
-H 'Content-Type: application/json' \
-H 'Pear-Client-Id: your-client-id' \
-H 'Pear-Client-Secret: your-client-secret' \
-d '{
"type": "dicefm",
"url": "https://dice.fm/event/lydia-lunchs-big-sexy-noise-mellowdeath-8th-feb-neue-zukunft-berlin-tickets-6929974d8aacf600016144de"
}'
PoshVIP 请求示例
curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
-H 'Content-Type: application/json' \
-H 'Pear-Client-Id: your-client-id' \
-H 'Pear-Client-Secret: your-client-secret' \
-d '{
"type": "poshvip",
"url": "https://posh.vip/e/west-village-halloween-bar-fest"
}'
注意事项
[!important] 重要提示
1. 时区处理
- 活动开始/结束时间使用的是平台的本地时间格式
- Eventbrite:
YYYY-MM-DDTHH:mm:ss(需转换为YYYY-MM-DD HH:mm:ss)- DICE.fm: ISO 8601 格式带时区(需移除时区信息)
- 如果平台未返回时区,通过经纬度坐标反查
2. 图片上传
- 外部海报 URL 会通过 Cloudinary 重新上传
- 避免直接使用外部链接,确保图片永久可用
- 解码 URL 中的特殊字符(如
%20→ 空格)3. 手续费计算
- 票价中包含手续费,创建产品时会自动分离计算
- 调用
TransactionFeeService.calcTransactionItemFeeFromFinalPrice()反向计算ticketPrice= 总价 -transactionItemFee4. 库存默认值
- 如果平台未返回库存数量:
validType === 'VALID': 默认 100- 其他情况: 默认 0
- DICE.fm 使用
limits.max_increments作为库存5. HTML 清理
- 活动描述中的 HTML 标签会被清理
- 保留换行和段落结构
- 移除
<p>、<br>以外的标签6. 认证凭据
Pear-Client-Id和Pear-Client-Secret必须与环境变量中的值完全匹配- 认证在
AdminGuard中验证(src/auth/admin.guard.ts)7. 活动创建步骤
- 创建后
creationStep自动设为ADD_EVENT_DETAILS- 前端需要继续完成后续步骤:
- 上传媒体 (
UPLOAD_EVENT_COVER)- 创建票务 (
ADD_EVENT_TICKETS)- 创建阵容 (
ADD_EVENT_LINEUP)- 选择模块 (
CHOOSE_MODULES)8. 错误处理
- 网络错误会重试 3 次(axios 默认配置)
- JSON 解析失败会记录到 console
- 所有业务错误统一返回
BadRequestException
支持的平台差异
Eventbrite
| 属性 | 值 |
|---|---|
| 数据源 | window.__SERVER_DATA__ 全局变量 |
| URL 要求 | 必须包含 /e/ 路径段 |
| 票种支持 | 支持多票种变体 (variants) |
| 时区 | 从页面数据直接获取 (event.start.timezone) |
| 价格单位 | 分(需要除以 100) |
| 库存 | ticketClasses.quantityRemaining |
| 海报 | components.eventHero.items[0].croppedLogoUrl940 |
DICE.fm
| 属性 | 值 |
|---|---|
| 数据源 | Meta 标签 + 公开 API |
| URL 要求 | https://dice.fm/event/* |
| Event ID | 24 位 hex 字符串(从 meta 标签或 JSON-LD 提取) |
| API 端点 | https://api.dice.fm/events/{eventId}/ticket_types |
| 价格单位 | 分(需要除以 100) |
| 库存 | ticket_types.limits.max_increments |
| 海报 | images.landscape 或 images.square |
| 时区 | dates.timezone |
| 阵容支持 | summary_lineup.top_artists(海报、顺序、是否 headliner) |
PoshVIP
| 属性 | 值 |
|---|---|
| 数据源 | self.__next_f.push + 内部 API |
| URL 要求 | https://posh.vip/e/{eventSlug} |
| Event ID | eventId 字段 |
| API 端点 | https://posh.vip/next-bff/events.getEventTickets?input={...} |
| 价格单位 | 元(直接使用) |
| 库存 | tickets.quantityAvailable |
| 海报 | flyer 字段 |
| 时区 | timezone 字段 |
| 描述处理 | 合并 shortDescription 和 description |
RA.co / Shotgun
[!warning] 开发中 这两个平台的抓取服务已定义但未完全实现,文档待补充。
相关文件
| 文件路径 | 说明 |
|---|---|
src/web-scraper/admin/event-scraper.admin.controller.ts |
控制器,处理请求路由 |
src/web-scraper/eventbrite-scraper.service.ts |
Eventbrite 抓取服务 |
src/web-scraper/dice-fm-scraper.service.ts |
DICE.fm 抓取服务 |
src/web-scraper/posh-vip-scraper.service.ts |
PoshVIP 抓取服务 |
src/web-scraper/base-event-import.service.ts |
基础服务类,包含通用逻辑 |
src/web-scraper/web-scraper.interface.ts |
接口定义(EventImportRequest、EventImportType) |
src/web-scraper/eventbrite-scraper.interface.ts |
Eventbrite 数据结构定义 |
src/auth/admin.guard.ts |
管理员认证守卫 |
依赖服务
| 服务 | 用途 | 代码位置 |
|---|---|---|
ProductEventService |
创建活动及票务产品 | src/product-event/product.event.service.ts |
ProductEventV2Service |
V2 活动创建逻辑 | src/product-event/v2/product.event.v2.service.ts |
CloudinaryService |
上传海报图片 | src/cloudinary/cloudinary.service.ts |
MapService |
根据经纬度查询时区 | src/map/map.service |
TransactionFeeService |
计算票务手续费 | src/transaction-fee/transaction-fee.service.ts |
UserMetaRepository |
验证用户存在性 | src/user-meta/userMeta.repository.ts |
更新日志
| 版本 | 日期 | 变更内容 |
|---|---|---|
| 1.0.0 | 2026-03-03 | 初始版本,支持 Eventbrite、DICE.fm、PoshVIP 三个平台 |
参考资料
- Eventbrite 活动页面示例
- DICE.fm API 文档
- [[Product Event Service|产品活动服务]]
- [[Transaction Fee Service|交易费用服务]]
- [[Product V2 Architecture|产品 V2 架构]]